Skip to main content

Redux Toolkit

以下文件描述的前因後果請參閱 Introduction

安裝

pnpm add @reduxjs/toolkit react-redux

建立所需的 Slice

使用 createSlice 來簡化 reducer 和 action 的建立

error.slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
// ...
const initialState: ErrorSlice = {
errorData: {}
};
const errorSlice = createSlice({
name: 'entry/error',
initialState,
reducers: {
onErrorDataChange: (state, action: PayloadAction<{ [key: string]: ErrorType }>) => {
return {
...state,
errorData: { ...state.errorData, ...action.payload }
};
}
}
});
export const { onErrorDataChange } = errorSlice.actions;
export default errorSlice.reducer;
fetchStatus.slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
// ...
const initialState: FetchStatusSlice = {
fetchStatus: {}
};
const fetchStatusSlice = createSlice({
name: 'entry/fetchStatus',
initialState,
reducers: {
onFetchStatusChange: (state, action: PayloadAction<{ [key: string]: boolean }>) => {
return {
...state,
fetchStatus: { ...state.fetchStatus, ...action.payload }
};
}
}
});
export const { onFetchStatusChange } = fetchStatusSlice.actions;
export default fetchStatusSlice.reducer;
user.slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
// ...
const initialState: UserSlice = {
currentUser: undefined
};
const userSlice = createSlice({
name: 'entry/user',
initialState,
reducers: {
onCurrentUserChange: (state, action: PayloadAction<UserType | undefined>) => {
return {
...state,
currentUser: action.payload
};
}
}
});
export const { onCurrentUserChange } = userSlice.actions;
export default userSlice.reducer;

使用 createApi 來建立一個 ApiSlice

先建立一個 baseApi.ts 設定共用的 api 設定

base.api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { casApiUrl } from '@/config';
import { getAccessTokenByCookie } from '@/utils/token.utility.ts';

export interface ResponseType {
data: any;
statusCode: number;
}
export const transformResponse = <T>(response: ResponseType): T => {
return response.data;
};
export const transformErrorResponse = (response: any) => {
return response.data?.error || response.error;
};

export const baseCasApi = createApi({
baseQuery: fetchBaseQuery({
baseUrl: casApiUrl,
prepareHeaders: headers => {
const accessToken = getAccessTokenByCookie();
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}

return headers;
},
credentials: 'include'
}),
reducerPath: 'baseCasApiReducer',
endpoints: () => ({})
});
  • cookie 使用 js-cookie 來處理相關的 func
  • 在 headers 的部分, 當有取得 accessToken 時, 就會將 Authorization 加入到 headers 中
  • credentials 用來控制跨域 (cross-site) 的請求是否應該攜帶認證資訊 (例如 cookies 和 HTTP authentication 等), 當設定為 include 時則即使是跨域的請求, 也會攜帶這些認證資訊

然後使用 injectEndpoints 基於 baseCasApi 建立所需的 authApi:

auth.api.ts
//...
export const authApi = baseCasApi.injectEndpoints({
endpoints: build => ({
fetchProfile: build.query<UserType, any>({
query: () => {
return {
url: '/auth/profile',
method: 'GET',
headers: {
'content-type': apiHeadersType.jsonType
}
};
},
transformResponse,
transformErrorResponse,
async onQueryStarted(_, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
dispatch(onCurrentUserChange(data));
dispatch(onFetchStatusChange({ isProfileFetching: false }));
} catch (err) {
console.error(err);
}
}
}),
renewToken: build.query<string, void>({
query: () => ({
url: `/auth/access-token/renew`,
method: 'GET',
headers: {
'content-type': 'application/json'
}
}),
transformResponse,
transformErrorResponse
}),
stVerify: build.query<string, { st: string; search: string; navigate: (url: string) => void }>({
query: ({ st }) => {
return {
url: `/auth/access-token/${st}`,
method: 'GET',
headers: {
'content-type': apiHeadersType.jsonType
}
};
},
transformResponse,
transformErrorResponse,
async onQueryStarted(arg, { dispatch, queryFulfilled }) {
try {
const { data } = await queryFulfilled;
saveAccessTokenCookie(data);
const params = new URLSearchParams(arg.search);
const queriedStr = Object.fromEntries([...params]);
delete queriedStr.st;
const redirectStr = isEmpty(queriedStr) ? '' : appendStr('', queriedStr);
arg.navigate(`${window.location.pathname}${redirectStr}`);
} catch (err) {
const { error } = err as { error: ErrorType };
dispatch(onCurrentUserChange(undefined));
dispatch(onErrorDataChange({ stVerifyError: error }));
arg.navigate(window.location.pathname);
}
dispatch(onFetchStatusChange({ isStVerifying: false }));
}
})
}),
overrideExisting: false
});
export const { useStVerifyQuery, useFetchProfileQuery } = authApi;
  • injectEndpoints 允許將新的 endpoints 注入到已存在的 API Slice 中 (baseCasApi), 而不用重新創建整個 API Slice, 這樣可以分開管理不同的 API endpoints 並且按需求來組合
  • transformResponsetransformErrorResponse 用來處理 response 的資料格式, 可以拉出來統一回傳的格式
  • overrideExisting: false 表示如果已經存在同名的 endpoints 則不會覆蓋它們, 防止無意間覆蓋已經定義的 endpoints

上面設定的 api (fetchProfile, renewToken, stVerify) 所相關的描述可參考實作流程

建立 Reducers

使用 combineReducers 將所有的 slice 進行整合

store.ts
import { combineReducers } from 'redux';
import errorSlice from '@/store/slice/error.slice.ts';
import fetchStatusSlice from './slice/fetch-status.slice';
import userSlice from './slice/user.slice';

const entityReducer = combineReducers({
error: errorSlice,
fetchStatus: fetchStatusSlice,
user: userSlice
});

建立 Store

使用 configureStore 來建立 store, 然後將剛剛所建立的 reducers 加入到 store 中

store.ts
import type { Middleware, MiddlewareAPI } from '@reduxjs/toolkit';
import { combineReducers, configureStore, isRejectedWithValue } from '@reduxjs/toolkit';
import { env } from '@/config.ts';
import { baseCasApi } from '@/services/cas';
import { authApi } from '@/services/cas/auth.ts';
import errorSlice, { ErrorType, onErrorDataChange } from '@/store/slice/error.slice.ts';
import { removeAccessTokenCookie, saveAccessTokenCookie } from '@/utils/token.utility.ts';
import fetchStatusSlice, { onFetchStatusChange } from './slice/fetch-status.slice';
import userSlice, { onCurrentUserChange } from './slice/user.slice';

const entityReducer = combineReducers({
error: errorSlice,
fetchStatus: fetchStatusSlice,
user: userSlice
});

interface EndpointAction {
initiate: (arg: any) => Promise<any>;
}
interface MetaArg {
endpointName: keyof typeof baseCasApi.endpoints;
originalArgs: any;
}

export const rtkQueryErrorHandler: Middleware = (api: MiddlewareAPI) => next => async action => {
const { dispatch } = api;
if (isRejectedWithValue(action)) {
const payload = action.payload as ErrorType;
switch (payload.key) {
case 'ACCESS_TOKEN_IS_EXPIRED':
try {
dispatch(onFetchStatusChange({ isProfileFetching: true }));
// Call renewToken API to get a new token
const renewAccessToken = await dispatch<any>(authApi.endpoints.renewToken.initiate()).unwrap();
saveAccessTokenCookie(renewAccessToken);

// Retry the original API request with the new token
const originalRequest = action.meta.arg as MetaArg;
const endpointName = originalRequest.endpointName;
const endpointAction = baseCasApi.endpoints[endpointName] as EndpointAction;
await dispatch<any>(endpointAction.initiate(originalRequest));
dispatch(onFetchStatusChange({ isProfileFetching: false }));
} catch (renewError) {
console.error('Token renewal failed', renewError);
dispatch(onCurrentUserChange(undefined));
dispatch(onFetchStatusChange({ isProfileFetching: false }));
dispatch(onErrorDataChange({ profile: payload }));
}
break;
case 'REFRESH_TOKEN_IS_EXPIRED':
removeAccessTokenCookie();
dispatch(onCurrentUserChange(undefined));
dispatch(onErrorDataChange({ profile: payload }));
dispatch(onFetchStatusChange({ isProfileFetching: false }));
break;
default:
break;
}
}
return next(action);
};

const store = configureStore({
reducer: {
[baseCasApi.reducerPath]: baseCasApi.reducer,
entityReducer
},
middleware: getDefaultMiddleware => getDefaultMiddleware({ serializableCheck: false }).concat(baseCasApi.middleware, rtkQueryErrorHandler),
devTools: env === 'development'
});

export type MainStoreState = ReturnType<typeof store.getState>;
export type MainStoreDispatch = typeof store.dispatch;
export default store;

rtkQueryErrorHandler 是一個 Middleware, 用來捕捉 rtk-query 的請求發生錯誤時的處理, 這裡我們的目的在於發送請求時會帶著 accessToken 去向 server 取得 user 的資料, 當 accessToken 過期的時候, 可以在這邊進行 renew token 的請求, 如果更新成功取得新的 accessToken, 就可以直接再次發送原本的請求, 就不需要寫額外的邏輯去處理 更新 token 並重新發送上次失敗的請求

note

在 Redux 中的 middleware 允許在 action 被 dispatch 至 reducer 之前進行一些額外的操作, 例如: log, 錯誤處理, 或者是對 action 進行修改等

另外在:

middleware: getDefaultMiddleware => getDefaultMiddleware({ serializableCheck: false }).concat(baseCasApi.middleware, rtkQueryErrorHandler);

這邊需注意 middleware 的順序 (relative issue)

在 React 中提供 Store

使用 Provider 將 Redux store 提供給整個 React

App.tsx
import { FC, Suspense } from 'react';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import Spin from '@/components/spin';
import Entry from '@/pages/entry';
import { GlobalProvider } from '@/provider/global.provider.tsx';
import store from './store';

const App: FC = () => {
return (
<Provider store={store}>
<BrowserRouter>
<GlobalProvider>
<Suspense
fallback={
<div className="bg-basic-100 h-screen w-full content-center">
<Spin className="m-auto" />
</div>
}
>
<Entry />
</Suspense>
</GlobalProvider>
</BrowserRouter>
</Provider>
);
};
export default App;

建立 Global Provide

global.provider.tsx
import React, { createContext, FC } from 'react';
import { useAuth } from '@/hooks/use-auth';
import { FetchStatusSlice } from '@/store/slice/fetch-status.slice';
import { UserType } from '@/store/slice/user.slice';

export const GlobalContext = createContext<GlobalContextType>({
fetchStatus: {},
currentUser: undefined
});
export const GlobalProvider: FC<{ children: React.ReactNode }> = ({ children }) => {
const { currentUser, fetchStatus } = useAuth();

return (
<GlobalContext.Provider
value={{
currentUser,
fetchStatus
}}
>
{children}
</GlobalContext.Provider>
);
};

這邊使用 Context 來提供 props, 因為當 auth 有所變化的時候希望能更新整個 component tree

note

使用 Context API 時, provider 傳遞的 props 如果不使用 useMemouseCallback 來包裝, 每次 provider 的 props 有所變動時, 會導致所有使用到 context 的地方都會被 re-render

useAuth 就是處理與 server 之間 authentication 相關的邏輯

use-auth.ts
// ...
const fetchStatusSelector = (state: MainStoreState) => state.entityReducer.fetchStatus;
const userSelector = (state: MainStoreState) => state.entityReducer.user;
const reSelector = createSelector(fetchStatusSelector, userSelector, (fetchStatus, user) => ({
currentUser: user.currentUser,
fetchStatus: fetchStatus.fetchStatus
}));

export const useAuth = () => {
// ...
return useSelector(reSelector);
};

在 Component 中使用

我們可以使用 useSelector 來取得 store 中的資料, 以及使用 useContext 來取得 GlobalContext 中的資料

Profile.tsx
// ...
const reSelector = createSelector(
(state: MainStoreState) => state.entityReducer.error,
({ errorData }) => ({ errorData })
);

const HomePage: FC = () => {
const { currentUser } = useContext(GlobalContext);
const { errorData } = useSelector(reSelector);
return (
<div>
<h1>Web2 HomePage</h1>
{isEmpty(currentUser) ? (
<div>
<div>Hi, guest!</div>
<div>Please login to see more.</div>
<div>
<Button text={'Sign In'} onClick={() => redirectWithUrl({ pathname: '/signin', includeRedirectUrl: true })} />
</div>
</div>
) : (
<div>
<div>Hi, {currentUser.email}!</div>
<div>
Here is your profile page: <Link to={'/profile'}>Click me</Link>{' '}
</div>
</div>
)}
// ...
</div>
);
};
export default HomePage;

/profile 的 Component 也是同樣方式, 而在 route 的設定我們可以限制如果沒有取得 user 的資料就不允許進入 /profile 頁面

route.tsx
const Routes: FC = () => {
const { currentUser } = useContext(GlobalContext);
const routes: CustomRouteObject[] = [
{
path: '/',
element: <Layout />,
children: [
{
path: '/',
element: <HomePage />
},
{
path: '/profile',
element: <ProfilePage />,
authRequired: true
}
].filter(route => !(route.authRequired && isEmpty(currentUser)))
} as CustomRouteObject,
{
path: '*',
element: <RedirectToHomePage />
}
];
return useRoutes(routes as RouteObject[]);
};
export default Routes;

Demo

  • 尚未登入 1.png
  • 跳轉至登入的 client 2.png
  • 登入成功後跳轉回 web 3.png
  • 有取得 user 資料, 則可以進入 /profile 頁面 4.png
  • 當驗證未通過時則顯示錯誤 5.png